Refresh live search members selectively on prerender_html events#5459
Conversation
A prerender_html event can't change a structured query's membership — only its members' renderings. So instead of re-querying the whole search on every such event, the entry-model live search now refreshes just the members the event carries newer HTML for, each through a conditional card+html GET keyed on the composite validator the member holds: a 304 keeps the current rendering (and the member's object identity, so a hydrated row stays live), a 200 swaps in the fresh entry. Full-text (matches) queries still re-run in full, since their membership is built from markdown. Paginated queries, composite/sparse rendering selections, and any member that can't be refreshed in isolation fall back to the coarse re-run, so nothing regresses. The instances live search makes the same distinction: it skips a prerender_html re-run for a structured query (instances carry no prerendered HTML and their membership is final after the index pass) and keeps re-running for a matches query. - runtime-common: filterHasMatches / wireFilterHasMatches, a single-leaf htmlQuery -> (format, renderType) helper, and an isEntrySingleDocument guard. - store: fetchCardEntry issues the conditional single-instance card+html GET. - search-entries resource: carries each member's index/html generations, reconstructs the composite validator, and performs the per-member refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5f99c52a35
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The search task consumed pendingSelectiveRefresh at task start, so a restart that cancelled a selective refresh mid-GET (an event on another realm) lost the queued invalidations with the cancelled run, and the stale member never refreshed. The queue is now consumed only at the point its content is applied or folded into the coarse re-run, so a cancelled run leaves it for the replacement run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR optimizes host live-search behavior on prerender_html realm events by selectively refreshing only affected search members (and only when the event’s generation is newer), avoiding costly whole-search re-queries when membership is stable.
Changes:
- Added selective per-member refresh to the host
search-entrieslive resource, including generation tracking and conditional one-cardcard+htmlGETs withIf-None-Match. - Added
StoreService.fetchCardEntry()to fetch a single entry+rendering without hydrating it into the store, plus runtime-common helpers/guards to support the flow. - Updated live instance-search gating to ignore
prerender_htmlfor structured queries (still re-run for full-textmatches), and expanded integration tests for the new behavior.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/runtime-common/search-entry.ts | Adds helpers to detect matches in wire filters and extract leaf htmlQuery rendering selection for one-card refresh. |
| packages/runtime-common/query.ts | Adds filterHasMatches() to detect full-text queries for event gating decisions. |
| packages/runtime-common/index.ts | Re-exports isEntrySingleDocument for host consumption. |
| packages/runtime-common/document-types.ts | Adds isEntrySingleDocument() guard for validating the single-entry JSON:API shape. |
| packages/host/app/services/store.ts | Adds fetchCardEntry() implementing conditional single-entry card+html / file-meta+html GET with ETag semantics. |
| packages/host/app/resources/search.ts | Stops re-running instance searches on prerender_html unless the query uses matches. |
| packages/host/app/resources/search-entries.ts | Implements queued/coalesced selective refresh on prerender_html, generation-aware candidate selection, and in-place replacement to preserve row identity. |
| packages/host/tests/integration/resources/search-entries-test.gts | Adds integration coverage for selective refresh, 304 identity preservation, upgrade-from-empty-html, fallback behavior, and real ETag round-trip. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Preview deploymentsHost Test Results 1 files ±0 1 suites ±0 2h 49m 21s ⏱️ + 1m 59s Results for commit 8223d0b. ± Comparison against earlier commit f38fc14. Realm Server Test Results 1 files ±0 1 suites ±0 12m 4s ⏱️ -53s Results for commit 8223d0b. ± Comparison against earlier commit f38fc14. |
- The instances live search keeps re-running on prerender_html events: its query mode excludes rows with an effective error, and a render error lands on the prerendered_html channel at-or-above the row's index generation, so membership can flip with no index event announcing it. (filterHasMatches, now unused, is removed.) - An item-only entry fieldset takes the coarse re-run for the same reason — without the html branch the search runs the live-search projection, whose membership excludes render-errored rows. - The selective refresh is computed by a pure helper and applied in the task body, whose awaits are cancellation points — a run cancelled by a restart can no longer splice a superseded result into the entries. The helper also stops issuing GETs once a newer run supersedes it. - Queued invalidations track the highest generation per URL instead of one max across events; generations are per-realm counters and are never compared across realms. - An equal-content 200 keeps the member's object identity, matching the coarse merge paths. - htmlQueryRenderingSelection returns undefined for a format-less eq leaf (format unconstrained has no single ?format= spelling) and validates its input structurally — malformed wire data reads as "no selection" instead of throwing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…churn - Any failure in the member-refresh pipeline — the GET, the stylesheet imports, or the rebuild — now falls back to the coarse re-run; previously a stylesheet-import rejection escaped the task and the member stayed stale with nothing surfaced. - Generation stamps no longer break row identity: a row whose visible content is unchanged keeps its object (and its hydration) across every merge path, adopting the fresh stamps in place. The invalidation fan-out re-indexes dependents whose content is often byte-identical, so without this every edit remounted every dependent row. - Clearing the query mid-refresh stops the remaining member GETs, same as teardown. - A prerender_html event with no usable generation takes the coarse re-run. - The test fixture echoes the true default htmlQuery (format only), and the stylesheet-failure test pins that its loader stub actually intercepted — the loader service replaces its loader instance on reset, so a stub installed on a stale instance silently no-ops. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lukemelia
left a comment
There was a problem hiding this comment.
I did some interactive analysis of this with AI to explore the edge cases I could think of, and it seem solid.
Background
When you have a search open in the app (say, a grid of card tiles in operator mode), that search is live: the realm notifies the browser when its contents change, and the search updates itself without a page reload.
Search results are mostly served as prerendered HTML — the server renders each card ahead of time and the search just displays that HTML, which is much cheaper than instantiating every card in the browser. Recently, producing that HTML was split out of indexing into its own background job: when a card changes, its searchable data is updated first (fast), and the HTML re-render follows afterwards on its own channel (slow — rendering is orders of magnitude more expensive). When the HTML lands, the realm sends the browser a
prerender_htmlevent listing which cards got fresh HTML.Until now, an open live search reacted to that event by re-running the entire search: re-send the query, re-fetch every result, rebuild every row. That's always correct, but consider what actually changed: the set of cards matching the query didn't move at all — only the pictures of some of them are newer. If your search shows 200 cards and one of them was re-rendered, we re-fetched all 200.
What this PR does
On a
prerender_htmlevent, the live search now updates only the cards the event names, and only if their HTML is actually newer than what's on screen — instead of re-running the whole search.To know "newer," every search result already carries two version numbers (called generations): one for its searchable data and one for its HTML. The event carries a generation too. So the resource can look at each row on screen and decide: is this card in the event's list, and is the event's generation higher than the HTML I'm holding? Only such rows need attention — often none do, and then the event costs nothing.
For each row that does need attention, the browser asks the server for just that one card's rendering, using standard HTTP caching: the request says "I have version
12:10— anything newer?" (anIf-None-Matchheader). The server answers either:Events can arrive while a refresh is already underway — another batch of HTML landing, or an ordinary index event, possibly for a different workspace the search spans. The queued work survives all of these: it is only discarded once it has actually been applied, and an index event arriving mid-refresh absorbs the pending HTML updates into its own (full) re-fetch rather than dropping them. Each invalidated card is tracked with its own generation, since generations are per-workspace counters and can't be compared across workspaces.
What still re-runs in full
matchesfilter, i.e. "search cards containing the word pigeon"). These match against a text extraction that is produced by the render, so aprerender_htmlevent genuinely can add or remove results — membership isn't stable. They keep the old behavior: re-run the whole search.prerender_htmlevent. So for these queries the event can change which cards appear at all, and only a full re-run picks that up.The older live-search resource that returns card instances deliberately keeps re-running on every
prerender_htmlevent, for the same reason as the card-data-only case above: it hides cards with render failures, so these events can change its results even though instances carry no HTML.Where the changes live
packages/host/app/resources/search-entries.ts— the live-search resource: decides which path an event takes, performs the per-card refreshes, splices results in place.packages/host/app/services/store.ts— newfetchCardEntry: the "give me this one card's rendering, unless mine is current" HTTP request. (The server endpoint it talks to already exists.)packages/runtime-common— small helpers: detect a free-text filter anywhere in a query, read the requested format off a query, validate the one-card response shape.Testing
Integration tests cover: an event refreshes only the named card and issues no full re-query; a 304 leaves the row object untouched; a card already up to date or not on screen causes no request at all; a card that matched before its HTML ever rendered gets upgraded; free-text and card-data-only queries still re-run in full; a failed one-card request falls back to the full re-run; events arriving mid-refresh — both another HTML batch and an index event on a different workspace — neither drop nor duplicate work; and a real (unstubbed) round trip against the server proving the version tag the browser reconstructs matches the server's, so the 304 path genuinely fires. Unit tests pin the helper that reads the requested format off a query, including malformed input.
🤖 Generated with Claude Code